portal: enable player removal from Edit Player view; fix QWeb group checks

- Replace nested remove form with a submit button using HTML5 formaction to post directly to /my/team/{team_id}/player/{player_id}/remove
- Prevent outer save form from intercepting submission; preserves CSRF; confirmation retained; redirects to team page
- Replace user_has_group helper with request.env.user.has_group in QWeb to prevent NoneType callable errors

Verification:
- Edit Player page renders without 500 error
- Clicking Remove removes the player and returns 303 redirect to /my/team/{team_id}
- Success notification displayed on team page

Notes:
- Removal route already implemented in controller (TeamManagementPortal.portal_remove_player)
This commit is contained in:
Denis Durepos 2025-08-13 10:43:39 -04:00
parent 78589bbcf3
commit 3ce8d051bc
12 changed files with 586 additions and 103 deletions

View file

@ -92,7 +92,7 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
@http.route(['/my/events', '/my/events/page/<int:page>'], type='http', auth='user', website=True)
def view_events(self, page=1, view_type='all', team_id=None, organization_id=None, assigned_user_id=None,
date_from=None, date_to=None, sortby=None, search=None, **kw):
date_from=None, date_to=None, sortby=None, search=None, no_default_dates=None, **kw):
"""Main events view with filtering and pagination"""
# Check access
@ -104,6 +104,17 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
if not (is_therapist or is_coach or user.has_group('base.group_system')):
raise AccessError(_("You don't have access to events."))
# Default date filter: from yesterday, similar to internal "Upcoming" behavior
# Only apply when user did not specify any date filters AND no explicit clear flag
if not date_from and not date_to and not no_default_dates:
try:
# Use date (not datetime) input format 'YYYY-MM-DD' for the portal date picker
yesterday = (fields.Date.today() - timedelta(days=1))
date_from = fields.Date.to_string(yesterday)
except Exception:
# Fallback using datetime in rare cases
date_from = (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d')
# Prepare base domain
domain = self._prepare_events_domain(view_type)
@ -167,7 +178,7 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
url='/my/events',
url_args={'view_type': view_type, 'team_id': team_id, 'organization_id': organization_id,
'assigned_user_id': assigned_user_id, 'date_from': date_from,
'date_to': date_to, 'sortby': sortby, 'search': search},
'date_to': date_to, 'sortby': sortby, 'search': search, 'no_default_dates': no_default_dates},
total=event_count,
page=page,
step=self._items_per_page,
@ -215,6 +226,7 @@ class EventsPortal(CustomerPortal, AccessControlMixin):
'organizations': organizations,
'treatment_professionals': treatment_professionals,
'can_edit': can_edit,
'no_default_dates': no_default_dates,
}
return http.request.render('bemade_sports_clinic.portal_events_list', values)

View file

@ -556,9 +556,13 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin):
# Get user's role
is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
# Document categories
categories = [('medical', 'Medical'), ('xray', 'X-Ray'), ('mri', 'MRI'),
('prescription', 'Prescription'), ('other', 'Other')]
# Document categories (aligned with model)
categories = [
('medical', 'Medical'),
('medical_imaging', 'Medical Imaging'),
('prescription', 'Prescription'),
('other', 'Other'),
]
values = {
'injury': injury,
@ -608,6 +612,7 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin):
# Create the document
document = request.env['sports.injury.document'].sudo().create({
'injury_id': int(injury_id),
'patient_id': injury.patient_id.id,
'name': post.get('document_name', name),
'description': post.get('description', ''),
'category': post.get('category', 'other'),
@ -632,11 +637,14 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin):
raise request.not_found()
try:
# Check access to the injury this document belongs to
injury = self._check_access_to_injury(document.injury_id.id)
# Prefer checking access via injury; if no injury, check via patient
if document.injury_id:
self._check_access_to_injury(document.injury_id.id)
else:
self._check_access_to_patient(document.patient_id.id)
except UserError:
raise request.not_found()
# Return the file for download
return request.make_response(
base64.b64decode(document.file_content),
@ -645,6 +653,72 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin):
('Content-Disposition', f'attachment; filename="{document.file_name}"'),
]
)
@http.route(['/my/patient/document/download/<int:document_id>'], type='http', auth='user')
def download_patient_document(self, document_id, **post):
"""Download a document linked to a patient (injury optional)."""
document = request.env['sports.injury.document'].sudo().browse(int(document_id))
if not document.exists():
raise request.not_found()
try:
# Access check based on patient (primary link)
self._check_access_to_patient(document.patient_id.id)
except UserError:
raise request.not_found()
return request.make_response(
base64.b64decode(document.file_content),
headers=[
('Content-Type', 'application/octet-stream'),
('Content-Disposition', f'attachment; filename="{document.file_name}"'),
]
)
@http.route(['/my/patient/document/upload'], type='http', auth='user', website=True, methods=['POST'])
def upload_patient_document(self, **post):
"""Upload a document directly to a patient (injury optional)."""
patient_id = post.get('patient_id')
if not patient_id:
return request.redirect('/my/players')
try:
patient = self._check_access_to_patient(patient_id)
except UserError as e:
return request.render('http_routing.http_error', {
'status_code': 403,
'status_message': 'Forbidden',
'error_message': str(e)
})
attachment = post.get('attachment')
if not attachment:
return request.redirect(f'/my/player?player_id={patient.id}&error=no_file')
try:
name = attachment.filename
file_content = attachment.read()
file_size = len(file_content)
# 10MB limit
if file_size > 10 * 1024 * 1024:
return request.redirect(f'/my/player?player_id={patient.id}&error=file_too_large')
# Create patient-linked document (injury optional)
request.env['sports.injury.document'].sudo().create({
'patient_id': patient.id,
'injury_id': int(post['injury_id']) if post.get('injury_id') else False,
'name': post.get('document_name', name),
'description': post.get('description', ''),
'category': post.get('category', 'other'),
'file_content': base64.b64encode(file_content),
'file_name': name,
'created_by_id': request.env.user.id,
})
return request.redirect(f'/my/player?player_id={patient.id}&success=document_uploaded')
except Exception as e:
_logger.error(f"Error uploading patient document: {e}")
return request.redirect(f'/my/player?player_id={patient.id}&error=upload_failed')
@http.route(['/my/injury/document/delete/<int:document_id>'], type='http', auth='user', website=True)
def delete_injury_document(self, document_id, **post):

View file

@ -159,13 +159,25 @@ class TeamStaffPortal(CustomerPortal):
# Show all injuries to treatment professionals, but only active ones to coaches
if is_treatment_prof:
injuries = player.injury_ids
else:
injuries = player.injury_ids.filtered(lambda r: r.stage == 'active')
# Patient documents for Documents tab (primary association now on patient)
patient_documents = http.request.env['sports.injury.document'].search([
('patient_id', '=', player.id)
], order='create_date desc, id desc')
# Categories for patient document uploads
categories = [
('medical', 'Medical'),
('medical_imaging', 'Medical Imaging'),
('prescription', 'Prescription'),
('other', 'Other'),
]
# Create patient_info dictionary for protected fields (when user is a treatment professional)
# No need for sudo() now that we have proper field-level access rights
patient_info = {}
@ -179,6 +191,8 @@ class TeamStaffPortal(CustomerPortal):
qcontext={
'player': player,
'injuries': injuries,
'patient_documents': patient_documents,
'categories': categories,
'team': team,
'page_name': 'my_player',
'is_treatment_prof': is_treatment_prof,

View file

@ -9,22 +9,49 @@ class InjuryDocument(models.Model):
_order = 'create_date desc, id desc'
name = fields.Char(string='Name', required=True)
injury_id = fields.Many2one('sports.patient.injury', string='Injury', required=True, ondelete='cascade')
patient_id = fields.Many2one('sports.patient', string='Patient', related='injury_id.patient_id', store=True)
# Refactor: documents are tied to a patient with optional injury linkage
patient_id = fields.Many2one('sports.patient', string='Patient', required=True, ondelete='cascade', index=True)
injury_id = fields.Many2one('sports.patient.injury', string='Injury', required=False, ondelete='cascade', index=True)
description = fields.Text(string='Description')
file_content = fields.Binary(string='File Content', required=True, attachment=False)
file_name = fields.Char(string='File Name')
file_size = fields.Integer(string='File Size', compute='_compute_file_size', store=True)
# Note: 'xray' and 'mri' kept for backward compatibility; displayed as 'Medical Imaging'
category = fields.Selection([
('medical', 'Medical Report'),
('xray', 'X-Ray'),
('mri', 'MRI'),
('medical', 'Medical'),
('medical_imaging', 'Medical Imaging'),
('xray', 'Medical Imaging'), # deprecated
('mri', 'Medical Imaging'), # deprecated
('prescription', 'Prescription'),
('other', 'Other'),
], string='Category', default='other', required=True)
created_by_id = fields.Many2one('res.users', string='Uploaded By', default=lambda self: self.env.user, required=True)
create_date = fields.Datetime(string='Upload Date')
@api.model_create_multi
def create(self, vals_list):
"""Ensure backward compatibility: if an injury is provided but patient is missing,
set patient_id from the injury's patient."""
for vals in vals_list:
if not vals.get('patient_id') and vals.get('injury_id'):
injury = self.env['sports.patient.injury'].browse(vals['injury_id'])
vals['patient_id'] = injury.patient_id.id
return super().create(vals_list)
@api.onchange('injury_id')
def _onchange_injury_id_sync_patient(self):
"""When selecting an injury, auto-set patient to the injury's patient."""
for rec in self:
if rec.injury_id:
rec.patient_id = rec.injury_id.patient_id
@api.constrains('injury_id', 'patient_id')
def _check_injury_belongs_to_patient(self):
"""Ensure the selected injury belongs to the chosen patient."""
for rec in self:
if rec.injury_id and rec.patient_id and rec.injury_id.patient_id != rec.patient_id:
raise ValidationError(_('The injury must belong to the selected patient.'))
@api.depends('file_content')
def _compute_file_size(self):
"""Compute the file size in bytes"""

View file

@ -138,6 +138,16 @@ class Patient(models.Model):
last_consultation_date = fields.Date(tracking=True)
active_injury_count = fields.Integer(compute="_compute_active_injury_count")
activity_count = fields.Integer(compute="_compute_activity_count")
# Documents linked to this patient (optionally to an injury)
document_ids = fields.One2many(
comodel_name="sports.injury.document",
inverse_name="patient_id",
string="Documents",
)
document_count = fields.Integer(
compute="_compute_document_count",
string="Document Count",
)
allergies = fields.Text(
groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_treatment_professional",
)
@ -287,6 +297,25 @@ class Patient(models.Model):
('res_id', '=', rec.id)
])
def _compute_document_count(self):
for patient in self:
patient.document_count = self.env['sports.injury.document'].search_count([
('patient_id', '=', patient.id)
])
def action_view_documents(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Documents'),
'res_model': 'sports.injury.document',
'view_mode': 'list,form',
'domain': [('patient_id', '=', self.id)],
'context': {
'default_patient_id': self.id,
},
}
def action_view_patient_form(self):
self.ensure_one()
return {

View file

@ -29,26 +29,26 @@
<ul class="nav nav-pills">
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'all' else ''}"
t-attf-href="/my/events?view_type=all#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}">
t-attf-href="/my/events?view_type=all#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}#{no_default_dates and '&amp;no_default_dates=1' or ''}">
All Events
</a>
</li>
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'my' else ''}"
t-attf-href="/my/events?view_type=my#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}">
t-attf-href="/my/events?view_type=my#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}#{no_default_dates and '&amp;no_default_dates=1' or ''}">
My Events
</a>
</li>
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'unassigned' else ''}"
t-attf-href="/my/events?view_type=unassigned#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}">
t-attf-href="/my/events?view_type=unassigned#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}#{no_default_dates and '&amp;no_default_dates=1' or ''}">
Unassigned Events
</a>
</li>
</ul>
</div>
<div class="col-md-6 text-end">
<a href="/my/events?view_type=all" class="btn btn-outline-secondary btn-sm">
<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>
</div>
@ -101,18 +101,33 @@
<!-- Second Row: Date Filters -->
<div class="row g-2">
<input type="hidden" name="no_default_dates" t-att-value="no_default_dates or ''"/>
<!-- Date From Filter -->
<div class="col-md-6">
<label for="date_from" class="form-label text-muted small">From Date</label>
<input type="date" id="date_from" name="date_from" class="form-control"
t-att-value="date_from" onchange="this.form.submit();"/>
<div class="input-group">
<input type="date" id="date_from" name="date_from" class="form-control"
t-att-value="date_from" onchange="this.form.submit();"/>
<button type="button" class="btn btn-outline-secondary"
title="Clear From Date"
onclick="document.getElementById('date_from').value=''; this.form.no_default_dates.value='1'; this.form.submit();">
<i class="fa fa-times"/>
</button>
</div>
</div>
<!-- Date To Filter -->
<div class="col-md-6">
<label for="date_to" class="form-label text-muted small">To Date</label>
<input type="date" id="date_to" name="date_to" class="form-control"
t-att-value="date_to" onchange="this.form.submit();"/>
<div class="input-group">
<input type="date" id="date_to" name="date_to" class="form-control"
t-att-value="date_to" onchange="this.form.submit();"/>
<button type="button" class="btn btn-outline-secondary"
title="Clear To Date"
onclick="document.getElementById('date_to').value=''; this.form.no_default_dates.value='1'; this.form.submit();">
<i class="fa fa-times"/>
</button>
</div>
</div>
</div>
</form>
@ -199,7 +214,8 @@
<td>
<strong t-esc="event.name"/>
<t t-if="event.description">
<br/><small class="text-muted" t-esc="event.description[:100] + '...' if len(event.description) > 100 else event.description"/>
<br/>
<div t-field="event.description" class="text-muted oe_no_empty"/>
</t>
</td>

View file

@ -581,17 +581,16 @@
<t t-foreach="documents" t-as="doc">
<tr>
<td><t t-esc="doc.name"/></td>
<td>
<span t-if="doc.category == 'medical'" class="badge badge-info">Medical</span>
<span t-elif="doc.category == 'xray'" class="badge badge-primary">X-Ray</span>
<span t-elif="doc.category == 'mri'" class="badge badge-warning">MRI</span>
<span t-elif="doc.category == 'prescription'" class="badge badge-success">Prescription</span>
<span t-else="" class="badge badge-secondary">Other</span>
</td>
<td><t t-esc="doc.description"/></td>
<td><span t-field="doc.created_by_id"/></td>
<td><span t-field="doc.create_date" t-options='{"widget": "date"}'/></td>
<td>
<td>
<span t-if="doc.category == 'medical'" class="badge badge-info">Medical</span>
<span t-elif="doc.category == 'medical_imaging' or doc.category == 'xray' or doc.category == 'mri'" class="badge badge-primary">Medical Imaging</span>
<span t-elif="doc.category == 'prescription'" class="badge badge-success">Prescription</span>
<span t-else="" class="badge badge-secondary">Other</span>
</td>
<td><t t-raw="doc.description or ''"/></td>
<td><span t-field="doc.created_by_id"/></td>
<td><span t-field="doc.create_date" t-options='{"widget": "date"}'/></td>
<td>
<a t-att-href="'/my/injury/document/download/%s' % doc.id" class="btn btn-sm bg-o-color-1 text-white">
<i class="fa fa-download"></i> Download
</a>

View file

@ -28,6 +28,82 @@
</div>
</div>
</div>
<!-- Team membership actions: Remove/Request Removal -->
<div class="row mb-4">
<div class="col-12">
<h5>Team Membership</h5>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Team</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="teams" t-as="team">
<tr>
<td>
<a t-attf-href="/my/team?team_id={{ team.id }}">
<t t-esc="team.name"/>
</a>
</td>
<td class="text-end">
<t t-set="is_treatment_prof" t-value="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system')"/>
<t t-set="is_team_staff" t-value="request.env.user.partner_id in team.staff_ids.user_ids.partner_id"/>
<!-- Direct remove for treatment professionals -->
<t t-if="is_treatment_prof">
<button type="submit"
class="btn btn-danger btn-sm"
t-att-formaction="'/my/team/' + str(team.id) + '/player/' + str(patient.id) + '/remove'"
formmethod="post"
formnovalidate="formnovalidate"
onclick="return confirm('Are you sure you want to remove this player from the team?');">
<i class="fa fa-user-times"/> Remove
</button>
</t>
<!-- Request removal for team staff (e.g., coaches) -->
<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="'#requestRemovalModalEdit' + str(team.id)">
<i class="fa fa-flag"/> Request Removal
</button>
<!-- Request Removal Modal -->
<div t-att-id="'requestRemovalModalEdit' + str(team.id)" class="modal fade" tabindex="-1" t-attf-aria-labelledby="requestRemovalModalEditLabel#{team.id}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" t-attf-id="requestRemovalModalEditLabel#{team.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/{{ patient.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="mb-3">
<label class="form-label">Reason</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-1 text-white">Submit Request</button>
</div>
</form>
</div>
</div>
</div>
</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">

View file

@ -138,10 +138,30 @@
<!-- Description -->
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="4"
t-esc="event.description"></textarea>
<label for="description_editor" class="form-label">Description</label>
<!-- Hidden field actually submitted -->
<input type="hidden" id="description" name="description" t-att-value="event.description or ''"/>
<!-- Simple rich-text editor -->
<div id="description_editor"
class="form-control"
style="min-height: 150px; overflow:auto;"
contenteditable="true">
<div t-field="event.description" class="oe_no_empty"/>
</div>
<small class="form-text text-muted">You can paste formatted text. The formatted content will be saved.</small>
</div>
<script type="text/javascript">
(function() {
const form = document.currentScript &amp;&amp; document.currentScript.closest('form');
if (!form) return;
const editor = form.querySelector('#description_editor');
const hidden = form.querySelector('#description');
if (!editor || !hidden) return;
form.addEventListener('submit', function() {
hidden.value = editor.innerHTML;
});
})();
</script>
</div>
</div>

View file

@ -7,24 +7,26 @@
<t t-call="portal.portal_docs_entry">
<t t-set="title">Teams</t>
<t t-set="url">/my/teams</t>
<t t-set="show_count" t-value="True"/>
<t t-set="placeholder_count">teams_count</t>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Players</t>
<t t-set="url">/my/players</t>
<t t-set="show_count" t-value="True"/>
<t t-set="placeholder_count">players_count</t>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Activities</t>
<t t-set="url">/my/activities</t>
<t t-set="show_count" t-value="True"/>
<t t-set="placeholder_count">activities_count</t>
<t t-set="config_card" t-value="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Events</t>
<t t-set="url">/my/events</t>
<t t-set="show_count" t-value="True"/>
<t t-set="config_card" t-value="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')"/>
<t t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Events</t>
<t t-set="url">/my/events</t>
<t t-set="placeholder_count">events_count</t>
<t t-set="config_card" t-value="True"/>
</t>
</t>
</div>
</xpath>
@ -92,46 +94,24 @@
<script type="text/javascript">
<![CDATA[
document.addEventListener('DOMContentLoaded', function() {
// Remove problematic placeholder_count attributes for coaches and treatment professionals
if (document.body.classList.contains('o_portal_user_coach') ||
document.body.classList.contains('o_portal_user_therapist')) {
console.log('Portal spinner fix: Detected coach/therapist user');
// Remove ALL placeholder_count attributes that cause spinner issues
const allCounterElements = document.querySelectorAll('[data-placeholder_count]');
console.log('Portal spinner fix: Found ' + allCounterElements.length + ' placeholder_count elements');
allCounterElements.forEach(function(el) {
const counterType = el.getAttribute('data-placeholder_count');
console.log('Portal spinner fix: Removing ' + counterType + ' counter');
el.removeAttribute('data-placeholder_count');
// Hide the parent card
const card = el.closest('.o_portal_index_card');
if (card && !card.classList.contains('d-none')) {
card.classList.add('d-none');
console.log('Portal spinner fix: Hiding card for ' + counterType);
}
});
// Force remove spinner immediately
// Scope any DOM tweaks to the portal home container and coaches only
const home = document.querySelector('.o_portal_my_home');
if (!home) return;
const isCoach = home.classList.contains('o_portal_user_coach');
if (!isCoach) {
// Do not alter placeholders or visibility for therapists or other users
return;
}
// Minimal safe tweak for coaches only: if Odoo leaves a long-running spinner, remove it after a grace period
setTimeout(function() {
const spinner = document.querySelector('.o_portal_doc_spinner');
if (spinner) {
spinner.remove();
console.log('Portal spinner fix: Spinner removed');
console.log('Portal spinner fix: Spinner removed for coach');
}
// Also remove any knowledge cards for therapists (they should only see activities)
const knowledgeCards = document.querySelectorAll('a[href*="/knowledge"]');
knowledgeCards.forEach(function(card) {
const parentCard = card.closest('.o_portal_index_card');
if (parentCard) {
parentCard.classList.add('d-none');
console.log('Portal spinner fix: Hiding knowledge card');
}
});
}
}, 2500);
});
]]>
</script>
@ -561,6 +541,14 @@
</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents" type="button" role="tab" aria-controls="documents" aria-selected="false">
<i class="fa fa-file"></i> Documents
<span class="badge badge-pill badge-secondary ms-1" t-if="patient_documents">
<t t-esc="len(patient_documents)"/>
</span>
</button>
</li>
</ul>
</div>
<div class="card-body">
@ -811,7 +799,7 @@
<h5 class="mt-4">Team Notes</h5>
<div class="card border-info">
<div class="card-body">
<p t-esc="player.team_info_notes" class="mb-0"/>
<div t-raw="player.team_info_notes" class="mb-0 oe_no_empty"/>
</div>
</div>
</t>
@ -819,6 +807,95 @@
</div>
</div>
<!-- Documents Tab -->
<div class="tab-pane fade" id="documents" role="tabpanel" aria-labelledby="documents-tab">
<t t-if="is_treatment_prof">
<div class="card mb-3">
<div class="card-header">
<i class="fa fa-upload"></i> Upload Document
</div>
<div class="card-body">
<form action="/my/patient/document/upload" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="patient_id" t-att-value="player.id"/>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Name</label>
<input type="text" name="document_name" class="form-control" placeholder="Document name"/>
</div>
<div class="col-md-4">
<label class="form-label">Category</label>
<select name="category" class="form-select">
<t t-foreach="categories" t-as="cat">
<option t-att-value="cat[0]" t-esc="cat[1]"/>
</t>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Link to Injury (optional)</label>
<select name="injury_id" class="form-select">
<option value="">-- None --</option>
<t t-foreach="injuries" t-as="injury">
<option t-att-value="injury.id" t-esc="injury.display_name"/>
</t>
</select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Optional description"></textarea>
</div>
<div class="col-md-8">
<label class="form-label">File</label>
<input type="file" name="attachment" class="form-control" required="required"/>
<small class="form-text text-muted">Max size 10MB</small>
</div>
<div class="col-md-4 d-flex align-items-end justify-content-end">
<button type="submit" class="btn bg-o-color-1 text-white">
<i class="fa fa-upload"></i> Upload
</button>
</div>
</div>
</form>
</div>
</div>
</t>
<t t-if="patient_documents">
<t t-call="portal.portal_table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Description</th>
<th>Created</th>
<th>By</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="patient_documents" t-as="doc">
<tr>
<td><span t-esc="doc.name"/></td>
<td><span t-esc="doc.category"/></td>
<td><span t-raw="doc.description or ''"/></td>
<td><span t-field="doc.create_date" t-options="{'widget': 'datetime'}"/></td>
<td><span t-esc="doc.created_by_id.display_name"/></td>
<td class="text-end">
<a t-att-href="'/my/patient/document/download/%s' % doc.id" class="btn btn-sm bg-o-color-1 text-white">
<i class="fa fa-download"></i> Download
</a>
</td>
</tr>
</t>
</tbody>
</t>
</t>
<div t-else="" class="text-center text-muted py-4">
<i class="fa fa-file fa-3x mb-3"></i>
<p>No documents uploaded</p>
</div>
</div>
<!-- Medical Information Tab (Treatment Professionals Only) -->
<div class="tab-pane fade" id="medical-info" role="tabpanel" aria-labelledby="medical-info-tab" t-if="is_treatment_prof">
<div class="row">

View file

@ -1,5 +1,72 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- Injury Document: backend views -->
<record id="view_injury_document_tree" model="ir.ui.view">
<field name="name">sports.injury.document.list</field>
<field name="model">sports.injury.document</field>
<field name="arch" type="xml">
<list string="Injury Documents" create="true" delete="true">
<field name="name"/>
<field name="patient_id"/>
<field name="injury_id"/>
<field name="category"/>
<field name="description"/>
<field name="created_by_id"/>
<field name="create_date"/>
</list>
</field>
</record>
<record id="view_injury_document_form" model="ir.ui.view">
<field name="name">sports.injury.document.form</field>
<field name="model">sports.injury.document</field>
<field name="arch" type="xml">
<form string="Injury Document">
<sheet>
<group>
<group>
<field name="name"/>
<field name="patient_id"/>
<field name="injury_id" domain="[('patient_id', '=', patient_id)]"/>
<field name="category"/>
</group>
<group>
<field name="file_content" filename="file_name"/>
<field name="file_name"/>
<field name="file_size" readonly="1"/>
</group>
</group>
<group>
<field name="description"/>
</group>
</sheet>
<footer/>
</form>
</field>
</record>
<record id="view_injury_document_search" model="ir.ui.view">
<field name="name">sports.injury.document.search</field>
<field name="model">sports.injury.document</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="patient_id"/>
<field name="injury_id"/>
<field name="category"/>
<field name="created_by_id"/>
<filter string="My Uploads" name="my_uploads" domain="[('created_by_id', '=', uid)]"/>
</search>
</field>
</record>
<record id="action_sports_injury_documents" model="ir.actions.act_window">
<field name="name">Injury Documents</field>
<field name="res_model">sports.injury.document</field>
<field name="view_mode">list,form</field>
<field name="context">{"search_default_injury_id": active_id, "default_injury_id": active_id}</field>
<field name="domain">[("injury_id", "=", active_id)]</field>
</record>
<record id="sports_patient_injury_view_form" model="ir.ui.view">
<field name="name">sports.patient.injury.view.form</field>
<field name="model">sports.patient.injury</field>
@ -7,6 +74,10 @@
<form>
<header>
<field name="stage" widget="statusbar"/>
<!-- Smart button: Documents -->
<button class="oe_stat_button" type="action" name="%(action_sports_injury_documents)d" icon="fa-file">
<field name="document_count" string="Documents" widget="statinfo"/>
</button>
<button name="action_verify_injury"
type="object"
class="oe_highlight"
@ -59,6 +130,38 @@
</list>
</field>
</page>
<page string="Documents">
<field name="document_ids">
<list string="Documents" editable="bottom">
<field name="name"/>
<field name="patient_id"/>
<field name="category"/>
<field name="description"/>
<field name="created_by_id" readonly="1"/>
<field name="create_date" readonly="1"/>
</list>
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="patient_id"/>
<field name="injury_id" domain="[('patient_id', '=', patient_id)]"/>
<field name="category"/>
</group>
<group>
<field name="file_content" filename="file_name"/>
<field name="file_name"/>
<field name="file_size" readonly="1"/>
</group>
</group>
<group>
<field name="description"/>
</group>
</sheet>
</form>
</field>
</page>
</notebook>
</sheet>
<chatter reload_on_post="True"/>
@ -80,6 +183,7 @@
<field name="treatment_professional_ids"
widget="many2many_avatar_user"/>
<field name="activity_ids" widget="list_activity"/>
<field name="document_count"/>
<field name="parental_consent"/>
<button name="action_view_injury_form" type="object"
title="Details" string="Details"

View file

@ -113,15 +113,18 @@
<header>
<field name="stage" widget="statusbar" />
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_report_injury" type="object" class="oe_stat_button" icon="fa-medkit">
<div class="o_stat_info">
<span class="o_stat_text">Report</span>
<span class="o_stat_text">Injury</span>
</div>
</button>
</div>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_report_injury" type="object" class="oe_stat_button" icon="fa-medkit">
<div class="o_stat_info">
<span class="o_stat_text">Report</span>
<span class="o_stat_text">Injury</span>
</div>
</button>
<button name="action_view_documents" type="object" class="oe_stat_button" icon="fa-file">
<field name="document_count" widget="statinfo" string="Documents"/>
</button>
</div>
<div class="oe_title"><h1>Patient Record</h1></div>
<group>
<group col="3">
@ -148,11 +151,11 @@
<field name="return_date"/>
<field name="team_info_notes"/>
</group>
<notebook>
<page string="Injuries">
<field name="injury_ids"/>
</page>
<page string="Treatment Notes">
<notebook>
<page string="Injuries">
<field name="injury_ids"/>
</page>
<page string="Treatment Notes">
<field name="treatment_note_ids">
<list string="Treatment Notes" editable="bottom">
<field name="date"/>
@ -162,8 +165,8 @@
<field name="user_id" readonly="1"/>
</list>
</field>
</page>
<page string="Contacts">
</page>
<page string="Contacts">
<group>
<group string="Patient Phone &amp; Email">
<field name="phone" widget="phone"/>
@ -190,10 +193,42 @@
</list>
</field>
</group>
</page>
</notebook>
</group>
</sheet>
</page>
<page string="Documents">
<field name="document_ids">
<list string="Documents" editable="bottom">
<field name="name"/>
<field name="injury_id" domain="[('patient_id', '=', parent.id)]"/>
<field name="category"/>
<field name="description"/>
<field name="created_by_id" readonly="1"/>
<field name="create_date" readonly="1"/>
</list>
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="patient_id"/>
<field name="injury_id" domain="[('patient_id', '=', patient_id)]"/>
<field name="category"/>
</group>
<group>
<field name="file_content" filename="file_name"/>
<field name="file_name"/>
<field name="file_size" readonly="1"/>
</group>
</group>
<group>
<field name="description"/>
</group>
</sheet>
</form>
</field>
</page>
</notebook>
</group>
</sheet>
<chatter reload_on_post="True"/>
</form>
</field>